Skip to content

6.3. Platform Agents

In one glance

  • You will: Read the one file that declares the agent as a Kubernetes workload, then watch the controller rebuild the pod after you delete it.
  • You need: The Skaffold loop from 6.2. Platform Install still running.
  • Time: about 18 minutes, reference.

Why declare a BYO Agent instead of a declarative kagent Agent?

This course uses spec.type: BYO. You ship the container image, and kagent only schedules it and fronts it on the cluster network. That choice decides who owns the agent's behavior, which makes it the most important decision on this page.

kagent supports two shapes of Agent. A declarative Agent hands kagent a model, a system prompt, and a tool list, and kagent composes and runs the loop for you inside its own runtime and container. A BYO ("bring your own") Agent inverts that: you ship the container image and own the agent contract, and kagent's job narrows to scheduling the workload and fronting it on the cluster network.

This course spent chapters 2 through 5 building a complete Google ADK application:

  1. a persistent session/task server (Chapter 2.4),
  2. guarded write actions (Chapter 3.1),
  3. PII and injection guardrails (Chapter 4.5),
  4. audit transactions,
  5. OpenTelemetry spans (Chapter 7).

A declarative Agent would discard that and re-express a thinner agent in kagent's vocabulary. spec.type: BYO keeps the validated application intact — the exact image that ran on the host in Chapter 5 runs unchanged in the cluster, still serving its own A2A card, still driving its own ADK Runner. That is why Chapter 6.0 states plainly that kagent does not replace ADK.

The trade-off is explicit:

  1. Choose BYO when you already have a working agent and want to preserve its runtime, framework, protocol surface, and lifecycle exactly — as this course does. You accept that kagent cannot introspect your prompt or tools.
  2. Choose a declarative Agent when you want kagent to own composition and are starting from a model and a tool list, not a container. You gain declarative convenience and lose runtime control.

What does the BYO Agent declare?

infra/kagent/agent.yaml is one custom resource — an object type kagent added to the Kubernetes API — that carries the full workload shape:

apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
  name: agentops-agent
  namespace: agentops
spec:
  type: BYO
  byo:
    deployment:
      image: agentops-agent:dev
      imagePullPolicy: IfNotPresent
      replicas: 1
      serviceAccountName: agentops-agent

Under spec.byo.deployment the same manifest also declares:

  1. the container env — the data-plane and A2A addressing contracts.
  2. resources — the compute envelope.
  3. podSecurityContext and securityContext — the hardening posture.
  4. volumeMounts/volumes — the state PVC and a small writable /tmp.

The sections below take each of those in turn. The logical image: agentops-agent:dev is a placeholder that Skaffold rewrites at deploy time — see the next section.

How does the custom resource become a running Deployment and Service?

A custom resource is declared intent, not a running process; a controller reconciles it into built-in Kubernetes objects.

kagent watches Agent resources. For type: BYO it turns spec.byo.deployment into a Deployment plus a Service on the A2A port 8080. That Deployment carries one replica, serviceAccountName: agentops-agent, imagePullPolicy: IfNotPresent, the hardened security contexts, the declared env, and the volume mounts.

You never author those objects. You author the Agent, and kagent keeps the derived objects converged to it.

Two rewrites happen before and during that reconcile:

  1. Skaffold rewrites .spec.byo.deployment.image. Plain kustomize does not know that a custom resource contains an image field, so infra/skaffold.yaml declares a resourceSelector that points Skaffold at that exact path: groupKind: Agent.kagent.dev, image: [.spec.byo.deployment.image]. Skaffold then replaces agentops-agent:dev with the pushed registry reference tagged by the abbreviated Git commit (tagPolicy.gitCommit.variant: AbbrevCommitSha). Chapter 6.1 covers why a commit tag improves provenance but is still a mutable reference.
  2. The overlay patches env values. The main local-gemini overlay selects gemini-3.5-flash by the named AGENT_MODEL field; the optional local overlay selects qwen3:4b-instruct. GKE keeps its qualified Gemini pin. Model backend is a data-plane change (Chapter 6.5).
sequenceDiagram
    participant S as Skaffold
    participant K as kagent controller
    participant D as Deployment + Service
    participant R as Kubernetes workload controllers
    participant P as Agent pod
    S->>K: apply Agent CR (image → registry:commit)
    K->>D: create Deployment (replicas 1, SA agentops-agent) + Service :8080
    D->>R: declare pod template and replica count
    R->>P: create replacement pod; scheduler selects its node
    P-->>P: serve A2A card at the advertised svc FQDN
    Note over P: kubectl delete pod
    R->>P: replace deleted pod with the same state PVC

Diagram in words: Skaffold supplies the image in the Agent resource, kagent creates its Deployment and Service, and Kubernetes controllers maintain the pod count. A deleted pod is replaced with the same PVC; the scheduler chooses a compatible node.

That last loop is the checkpoint at the end of this page: delete the pod, and Kubernetes replaces it against the same claim. kagent owns the derived Deployment, so change the Agent resource instead of hand-editing that generated object.

Which environment variables preserve the data plane?

Moving to Kubernetes must not change what the agent does; it only repoints the unchanged app at cluster services instead of host processes. The env block does that repointing:

- name: AGENT_MODEL_PROVIDER
  value: openai-compatible
- name: AGENT_MCP_URL
  value: http://agentgateway:3000/mcp
- name: OPENAI_BASE_URL
  value: http://agentgateway:4000/v1
- name: OPENAI_API_KEY
  value: agentgateway
- name: OTEL_EXPORTER_OTLP_ENDPOINT
  value: http://otel-collector:4318
- name: AGENT_STATE_DIR
  value: /app/state

The decisive property is what is missing from that block: no upstream provider credential ever enters the agent pod. The gateway holds real upstream auth.

Variable Points at Why it matters
AGENT_MODEL_PROVIDER the same provider the app selected in Chapter 2.2 the application code is unchanged, regardless of which model sits behind the gateway
AGENT_MCP_URL agentgateway :3000 root_agent registers one remote McpToolset instead of six local read functions (Chapter 6.4)
OPENAI_BASE_URL agentgateway :4000 model traffic goes to the gateway, not straight to a provider
OPENAI_API_KEY the gateway's own auth check agentgateway is the non-secret demo marker the gateway enforces (Chapter 5.5 / 6.5), not a real credential
OTEL_EXPORTER_OTLP_ENDPOINT the collector :4318 spans leave the pod for the in-cluster collector
AGENT_STATE_DIR the mounted state PVC the one writable persistent path, because the pod's root filesystem is read-only

The main local-gemini overlay uses Gemini API behind that endpoint; local is the optional Ollama profile, while GKE uses Vertex AI. Their provider credentials and network policies differ.

Owned by 6.4. Platform Tools for the MCP route and 6.5. Platform Gateway for the gateway and the model backend.

Why does the agent advertise a different A2A host than it binds?

Every network server has two distinct addresses: where it listens (the bind address) and where clients should call it (the advertised address). Conflating them silently breaks discovery, because a listen address like 0.0.0.0 is not a routable endpoint. The A2A block encodes both, plus the port kagent fronts:

- name: AGENT_A2A_HOST
  value: agentops-agent.agentops.svc.cluster.local
- name: AGENT_A2A_BIND_HOST
  value: 0.0.0.0
- name: AGENT_A2A_PORT
  value: "8080"

AGENT_A2A_BIND_HOST=0.0.0.0 is the listen address: Uvicorn binds all interfaces so the kubelet and the gateway can reach the pod. It is also baked into the image (Dockerfile sets ENV AGENT_A2A_BIND_HOST=0.0.0.0 and EXPOSE 8080), while the host default is loopback-only. AGENT_A2A_HOST=agentops-agent.agentops.svc.cluster.local is the advertised host: server.py builds the agent card url from it and hands it to callers.

url=f"{settings.a2a_protocol}://{settings.a2a_host}:{settings.a2a_port}/",

The split is deliberate, and config.py records why:

# Never advertise 0.0.0.0: it is a listener, not a callable endpoint.
a2a_bind_host: str = Field(default="127.0.0.1", min_length=1)
a2a_host: str = Field(default="localhost", min_length=1)

A third address completes the picture — external clients never call 8080 directly. They port-forward agentgateway :3001 (Chapter 6.5), which fronts A2A; direct pod port 8080 is reserved for diagnosis.

flowchart TD
    Client[Local client] -->|port-forward| Gateway["agentgateway :3001 — external hop"]
    Gateway -->|A2A| Bind["AGENT_A2A_BIND_HOST 0.0.0.0:8080 — listen"]
    Bind --> Card["agent card url = AGENT_A2A_HOST svc FQDN :8080 — advertised"]

The pitfall: set AGENT_A2A_HOST to 0.0.0.0 or leave it at the loopback default in-cluster, and the card resolves to an uncallable address — clients fail card resolution even though the pod is healthy and serving.

What is the ModelConfig for?

The BYO pod reads its environment, not the live ModelConfig resource.

ModelConfig declares the gateway contract for declarative kagent consumers. During rendering, this repository's Kustomize replacements also copy its model name into the BYO AGENT_MODEL environment variable. Apply the rendered workload to change that environment; editing only the live ModelConfig does not reconfigure the running BYO app.

Deeper: what the ModelConfig declares

ModelConfig/agentgateway in infra/kagent/modelconfig.yaml is kagent's declarative model contract — the object a declarative Agent or any other kagent-managed consumer would read to reach a model:

spec:
  provider: OpenAI
  model: gemini-3.5-flash
  apiKeySecret: agentgateway-client
  apiKeySecretKey: OPENAI_API_KEY
  openAI:
    baseUrl: http://agentgateway.agentops.svc.cluster.local:4000/v1
    timeout: 120

Because the BYO agent brings its own env (the OPENAI_BASE_URL/OPENAI_API_KEY above), the ModelConfig does not inject the model into the BYO pod. It documents and enables the platform's OpenAI-compatible endpoint at agentgateway :4000 so the cluster has one declared model contract instead of an implicit one. apiKeySecret references the agentgateway-client Secret, whose value is the non-secret SDK marker (Chapter 6.5). The local overlay patches spec.model to qwen3:4b-instruct; the GKE overlay keeps gemini-3.5-flash while preserving the same gateway baseUrl.

How is the pod hardened?

Suppose an attacker got code execution inside this pod. They would not be root. They could write only to /app/state and a 128 MiB /tmp, would hold no Kubernetes API token, and could reach nothing on the network except the gateway and the collector.

The BYO deployment declares a defense-in-depth posture and a bounded compute envelope so one workload cannot escalate privileges or starve neighbors:

  • UID/GID/fsGroup 10001 and runAsNonRoot. fsGroup is what gives that non-root process write access to the mounted volume.
  • RuntimeDefault seccomp — the container runtime's default filter on which system calls the process may make.
  • no privilege escalation and all capabilities dropped.
  • read-only root filesystem, a writable 1 Gi RWO state PVC at /app/state, and a 128 MiB /tmp emptyDir.
  • CPU/memory requests and limits.
  • service-account token automount disabled — the agentops-agent ServiceAccount in serviceaccounts.yaml sets automountServiceAccountToken: false, because the agent never calls the Kubernetes API.

The compute envelope is concrete, not decorative:

resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    cpu: "1"
    memory: 1536Mi

These numbers, plus the 128 MiB /tmp and the 1 Gi PVC, are what the namespace ResourceQuota and LimitRange are sized against. A ResourceQuota caps what a whole namespace may request; a LimitRange supplies defaults for pods that declare none.

Chapter 6.5 owns that math, including one surge pod per rolling deploy so Skaffold rollouts never deadlock. Do not re-derive it here; cross-link it.

A read-only root with an explicit writable state mount also constrains what a compromised process can persist. The default-deny egress rules (Chapters 6.5 and 4.6) constrain where it can send data.

The optional GKE gateway and MLflow identities obtain ambient cloud credentials through the metadata server, so Workload Identity Federation — mapping a workload identity to cloud IAM without a static key — does not require an automounted Kubernetes API token.

How does Kubernetes know the processes are actually ready?

Kubernetes calls an HTTP endpoint inside a container on a schedule. That probe is run by the kubelet — the Kubernetes agent on each node.

The agent image exposes two application-level endpoints on both network servers:

  • /livez proves the event loop can answer a trivial request.
  • On MCP, /healthz opens the agent-published runtime database read-only and verifies integrity, required tables, the current audit version, and the exact non-partial unique idempotency index.
  • On A2A, application startup owns first-boot initialization and additive runtime migration. /healthz then runs the same read-only database probe, verifies the writable state directory, and checks the persistent session/task store.

Health polling never creates or migrates state. On a fresh volume the A2A startup publishes the database atomically, then prepares it before serving; MCP readiness remains false for missing, legacy, or failed-migration state.

The asymmetry is worth making explicit: both workloads publish the same endpoints, but only one has them wired as kubelet probes.

Workload Exposes /livez, /healthz? Wired as k8s probe? What restarts it on failure
agentops-agent (A2A) Yes No — BYO v1alpha2 exposes no probe fields Kubelet restarts an exited container; ReplicaSet replaces a deleted pod; a hung process has no liveness probe
agentops-mcp Yes Yes — startupProbe, readinessProbe, livenessProbe The kubelet, via the wired probes (restart / drop from endpoints)

The pinned kagent v1alpha2 BYO deployment schema does not expose container probe or pod termination-grace fields. The course therefore does not pretend the controller-created A2A pod has probes it cannot declare. Open a direct checkpoint in one terminal:

kubectl -n agentops port-forward svc/agentops-agent 8080:8080

Leave the forward running. From another terminal, call both endpoints:

curl -fsS http://localhost:8080/livez
curl -fsS http://localhost:8080/healthz

Do not patch the generated Deployment behind the controller: reconciliation can overwrite that drift.

Deeper: which workload wires the probes, and what the infra check asserts

The static MCP Deployment — not this BYO Agent — is where those endpoints are actually wired as startupProbe, readinessProbe, and livenessProbe; Chapter 6.4 owns that wiring and the shared-PVC read coherence, and scripts/check-infra.sh asserts the exact rendered paths and that the 15-second pod grace period exceeds AGENT_DRAIN_TIMEOUT_S=10. Both MCP HTTP transports and A2A run under Uvicorn with that bounded graceful-shutdown timeout, so SIGTERM stops new work and gives in-flight requests time to finish before Kubernetes may send SIGKILL.

When kagent adds those BYO fields, wire the same endpoints through the Agent resource and remove this limitation.

How do you verify the resource?

Inspect the live resource while Skaffold is running:

kubectl -n agentops get agents.kagent.dev
kubectl -n agentops get pods,pvc,svc
kubectl -n agentops describe agent.kagent.dev/agentops-agent

Expected: one ready agent pod, bound agentops-agent-state, and ClusterIP agentops-agent on 8080.

What proves this page worked?

Delete only the agent pod and watch Kubernetes replace it. Confirm the replacement mounts the same PVC and the agent card is available through gateway port 3001. This tests pod replacement, not zone/PV disaster recovery or recovery of a hung process.

You are done when:

  • kubectl -n agentops get agents.kagent.dev lists agentops-agent instead of returning nothing.
  • kubectl -n agentops get pods,pvc,svc shows one ready agent pod, a bound agentops-agent-state claim, and ClusterIP agentops-agent on 8080.
  • Deleting only the agent pod brings a replacement back, mounted on that same agentops-agent-state claim.
  • The agent card still resolves through gateway port 3001 once the replacement pod is ready.
  • You can say which address the pod binds (0.0.0.0) and which address its card advertises (agentops-agent.agentops.svc.cluster.local), and why they must differ.

Continue to 6.4. Platform Tools when the pod you deleted has come back on the same state claim.